/*
Reads a MIDI object created by Tonejs/Midi and converts its data to Overwatch workshop rules.
*/


"use strict";

// Range of notes on the overwatch piano, 
// based on the MIDI scale (0 - 127).
// One integer is one semitone.
const PIANO_RANGE = Object.freeze({
    MIN: 24,
    MAX: 88
});
const OCTAVE = 12;

/* Settings for parsing the midi data.
    - startTime: time (seconds) in the midi file when this script begins reading the data
    - voices: amount of bots required to play the resulting script, maximum amount of pitches allowed in any chord.
              At least 6 recommended to make sure all songs play back reasonably well
*/
const CONVERTER_SETTINGS_INFO = Object.freeze({
    startTime:  {MIN:0, MAX:Infinity,   DEFAULT:0},
    voices:     {MIN:6, MAX:11,         DEFAULT:6},
});

const DEFAULT_SETTINGS = {
    startTime:  CONVERTER_SETTINGS_INFO["startTime"]["DEFAULT"],
    voices:     CONVERTER_SETTINGS_INFO["voices"]["DEFAULT"],
};

// Maximum amount of elements in a single array of the song data rules.
// Overwatch arrays are limited to 1000 elements per dimension.
const MAX_OW_ARRAY_SIZE = 1000;

// Maximum amount of array elements allowed across all song data rules. 
// Total Element Count (TEC) increases by 2 per number element in an array, and is limited to 20 000 (including the base script).
const MAX_TOTAL_ARRAY_ELEMENTS = 9000;

// Amount of decimals in the time (seconds) of each note
const NOTE_PRECISION = 3;

const CONVERTER_WARNINGS = {
    TYPE_0_FILE: "WARNING: The processed file is a type 0 file and may have been converted incorrectly.\n"
};

const CONVERTER_ERRORS = {
    NO_NOTES_FOUND: `Error: no notes found in MIDI file in the given time range.\n`
};

// Maximum time interval (milliseconds) between two chords
const MAX_TIME_INTERVAL = 9999;

// Lengths (in digits) of song data elements when compression is used.
const SONG_DATA_ELEMENT_LENGTHS = {
    pitchArrays: 2,
    timeArrays: 4,
    chordArrays: 2
};

// Maximum length (in digits) of a compressed array element. See the compressSongArrays function for more info. 
const COMPRESSED_ELEMENT_LENGTH = 7;


function convertMidi(mid, settings={}, isCompressionEnabled=true) {
    /*
    param mid:  a Midi object created by Tonejs/Midi
    param settings: a JS object containing user parameters for 
                    parsing the midi data, see DEFAULT_SETTINGS for an example
    param isCompressionEnabled: boolean, determines whether or not to compress song data (see compressSongArrays for more info)

    Return: a JS object, containing:
        string rules:           Overwatch workshop rules containing the song Data,
                                or an empty string if an error occurred
        int transposedNotes:    Amount of notes transposed to the range of the Overwatch piano
        int skippedNotes:       Amount of notes skipped due to there being too many pitches in a chord
        float duration:         Full duration (seconds) of the MIDI song 
        float stopTime:         The time (seconds) when the script stopped reading the MIDI file, 
                                either due to finishing the song or due to reaching the maximum allowed amount of data 
        string[] warnings:      An array containing warnings output by the script
        string[] errors:        An array containing errors output by the script
    */

    if (Object.keys(settings).length !== Object.keys(CONVERTER_SETTINGS_INFO).length) {
        settings = DEFAULT_SETTINGS;
    }

    let midiInfo = readMidiData(mid, settings);
    let workshopRules = "";

    let arrayInfo = {};
    if (midiInfo.chords.size !== 0) {
        arrayInfo = convertToArray(midiInfo.chords, mid.duration, isCompressionEnabled);

        workshopRules = writeWorkshopRules(arrayInfo.owArrays, settings["voices"], isCompressionEnabled);
    }
    
    return { 
        workshopRules:      workshopRules, 
        skippedNotes:       midiInfo.skippedNotes, 
        transposedNotes:    midiInfo.transposedNotes,
        duration:           mid.duration,
        stopTime:           arrayInfo.stopTime,
        warnings:           midiInfo.warnings,
        errors:             midiInfo.errors
    };
}


function readMidiData(mid, settings) {
    // Reads the contents of a Midi object (generated by Tonejs/Midi)
    // to a map with times (float, rounded to NOTE_PRECISION decimals) of chords as keys 
    // and pitches (array of ints) in those chords as values

    let chords = new Map();

    let skippedNotes = 0;
    let transposedNotes = 0;

    for (let track of mid.tracks) {

        if (track.channel === 9) {
            // MIDI channel 9 is used for percussion, ignore this track
            continue;
        }
        
        for (let note of track.notes) {
            if (note.velocity === 0) {
                // Note off event. Because the overwatch piano has no sustain pedal, this is not needed
                continue;
            }
            if (note.time < settings["startTime"]) {
                continue;
            }

            let notePitch = note.midi;
            if (notePitch < PIANO_RANGE["MIN"] || notePitch > PIANO_RANGE["MAX"]) {
                transposedNotes += 1
                notePitch = transposePitch(notePitch);
            }

            // Move pitch to the 0-64 range
            notePitch -= PIANO_RANGE["MIN"];

            let noteTime = roundToPlaces(note.time, NOTE_PRECISION);

            if (!chords.has(noteTime)) {
                chords.set( noteTime, [notePitch] );

            } else {
                // The same pitch can be found multiple times in a chord 
                // if e.g. two instruments double each other.
                // Only the piano is used in overwatch, so discard any double pitches
                if (!chords.get(noteTime).includes(notePitch)) {

                    if (chords.get(noteTime).length < settings["voices"]) {
                        chords.get(noteTime).push(notePitch);
                    } else {
                        skippedNotes += 1;
                    }
                }
            }
        }
    }

    let warnings = [];
    let errors = [];

    if (chords.size === 0) {
        errors.push(CONVERTER_ERRORS["NO_NOTES_FOUND"]);
    } else {
        // Sort by keys (times)
        chords = new Map([...chords.entries()].sort( (time1, time2) => 
                                                    { return roundToPlaces(parseFloat(time1) 
                                                      - parseFloat(time2), NOTE_PRECISION) } ));
    }

    if (mid.tracks.length === 1) {
        // Type 0 midi files have only one track
        warnings.push(CONVERTER_WARNINGS["TYPE_0_FILE"]);
    }

    return { 
        chords, 
        skippedNotes, 
        transposedNotes, 
        warnings, 
        errors 
    };
}


function convertToArray(chords, songDuration, isCompressionEnabled) {
    // Converts the contents of the chords map 
    // to the format used by the Overwatch gamemode (documented in Readme)

    let owArrays = {
        pitchArrays: [],
        timeArrays: [],
        chordArrays: []
    };

    let pitchArrayElements = 0;
    let timeArrayElements = 0;
    let chordArrayElements = 0;

    // Size measured by the amount of array elements used. See compressSongArrays for details on compression
    let uncompressedSize = 0;
    let compressedSize = 0;

    // Time of the first note
    let prevTime = chords.keys().next().value;
    
    // Time when the converter stopped reading midi data, either due to finishing the song 
    // or due to reaching the maximum allowed amount of data.
    let stopTime = 0;

    for (let [currentChordTime, pitches] of chords.entries()) {

        pitchArrayElements += pitches.length;
        timeArrayElements += 1;
        chordArrayElements += 1;

        uncompressedSize = pitchArrayElements + timeArrayElements + chordArrayElements;
        compressedSize = Math.ceil(
                                   (pitchArrayElements * SONG_DATA_ELEMENT_LENGTHS["pitchArrays"]
                                    + timeArrayElements * SONG_DATA_ELEMENT_LENGTHS["timeArrays"]
                                    + chordArrayElements * SONG_DATA_ELEMENT_LENGTHS["chordArrays"]
                                    )
                                   / COMPRESSED_ELEMENT_LENGTH);

        if ( (isCompressionEnabled ? compressedSize : uncompressedSize) > MAX_TOTAL_ARRAY_ELEMENTS) {
            // Maximum amount of elements reached, stop adding 
            stopTime = currentChordTime;
            break;
        }

        // One chord in the song consists of 
        // A) the time interval (milliseconds) between current chord and previous chord
        owArrays["timeArrays"].push(Math.min(roundToPlaces((currentChordTime - prevTime) * 1000, 0), MAX_TIME_INTERVAL));
        // B) the amount of pitches in the chord
        owArrays["chordArrays"].push(pitches.length);
        // and C) the pitches themselves 
        for (let newPitch of pitches.sort()) {
            owArrays["pitchArrays"].push( newPitch );
        }

        prevTime = currentChordTime;
    }

    if (stopTime === 0) {
        // The entire song was added to owArrays
        stopTime = songDuration;
    }

    return { owArrays, stopTime };
}


function compressSongArrays(owArrays) {
    /*
    Compresses the song arrays by clumping several elements into one integer. For example:
    (maximum element length = 3)
    Data:               Array(12, 0, 312, 2, 56, 23, 23, 4, 153, 123, 110, ...)
    Compressed data:    Array(0120003, 1200205, 6023023, 0041531, 23110...)

    Total Element Count (TEC) is the limit to how much data can be pasted into the workshop prior to starting the custom game.
    The amount of data generated during runtime (by e.g. decompression) is far less limited.
    
    When pasting numbers (all of them act like floats regardless of their actual content) into the workshop, 
    the increase in TEC is only affected by the amount of numbers, not their individual sizes. String arrays could be used 
    for far better efficiency instead of number arrays (128 characters per array element instead of 7, 
    and not limited to digits 0-9), but there is no straightforward way to read them with workshop 
    due to lack of simple string methods. Up to 7 digits can be used per number 
    without running into issues with floating point precision. 

    Things to look into later: delta encoding/compression, RLE for chordArrays/timeArrays
    */

    let compressedArrays = {
        pitchArrays: [],
        timeArrays: [],
        chordArrays: []
    };

    for (let [arrayName, songArray] of Object.entries(owArrays)) {
        // Convert elements to strings, prepend with zeroes if an element is not long enough
        songArray = songArray.map(x => x.toString().padStart(SONG_DATA_ELEMENT_LENGTHS[arrayName], "0"));

        let stringBuffer = songArray.join("");

        // Write to compressedArray 7 numbers at a time
        for (let i = 0; i < stringBuffer.length; i += COMPRESSED_ELEMENT_LENGTH) {

            let newElement = stringBuffer.slice(i, i + COMPRESSED_ELEMENT_LENGTH);
            compressedArrays[arrayName].push(newElement);
        }
    }
    
    return compressedArrays;
}


function writeWorkshopRules(owArrays, maxVoices, isCompressionEnabled) {
    // Creates workshop rules containing the song data in arrays, 
    // ready to be pasted into Overwatch

    let workshopRules = [];

    // The first rule contains general data: amount of voices (bots) required, max array size, etc.
    let firstRule = [`rule(\"General song data\"){event{Ongoing-Global;}actions{\n` +
                     `Global.maxBots = ${maxVoices};\n` +
                     `Global.maxArraySize = ${MAX_OW_ARRAY_SIZE};\n` +
                     `Global.isCompressionEnabled = ${isCompressionEnabled};\n`];

    if (isCompressionEnabled) {

        owArrays = compressSongArrays(owArrays);
        
        firstRule += `Global.compressedElementLength = ${COMPRESSED_ELEMENT_LENGTH};\n` +

        // Values needed for decompression: 
        // lengths of the last elements of the compressed arrays (which are required due to 
        // the fact that data such as 00406 turns into 406 when pasted as an integer) and 
        // lengths of the individual song data elements.
        `Global.compressionInfo = Array(Array(${owArrays["pitchArrays"].slice(-1)[0].length},` +
                                             `${owArrays["timeArrays"].slice(-1)[0].length},` +
                                             `${owArrays["chordArrays"].slice(-1)[0].length}),` +
                                       `Array(${SONG_DATA_ELEMENT_LENGTHS["pitchArrays"]},` +
                                             `${SONG_DATA_ELEMENT_LENGTHS["timeArrays"]},` +
                                             `${SONG_DATA_ELEMENT_LENGTHS["chordArrays"]}));`;
    }

    firstRule += `}}\n`;
    workshopRules.push(firstRule);

    // Write all 3 arrays in owArrays to workshop rules
    for (let [arrayName, songArray] of Object.entries(owArrays)) {

        // Index of the current overwatch array being written to
        let owArrayIndex = 0;

        // Index of the current JS array element being written
        let index = 0;
        while (index < songArray.length) {

            let actions = `Global.${arrayName}[${owArrayIndex}] = Array(${songArray[index]}`;
            owArrayIndex += 1;
            index += 1;
            
            // Write 999 elements at a time to avoid going over the array size limit 
            for (let j = 0; j < MAX_OW_ARRAY_SIZE - 1; j++) {

                if (index >= songArray.length) {
                    break;
                }

                actions += `, ${songArray[index]}`;
                index += 1;
            }

            let newRule = `rule(\"${arrayName}\"){event{Ongoing-Global;}` +
                          `actions{${actions});}}\n`;       
            workshopRules.push(newRule);
        }
    }

    return workshopRules.join("");
}


function transposePitch(pitch) {
    while (pitch < PIANO_RANGE["MIN"]) {
        pitch += OCTAVE;
    }
    while (pitch > PIANO_RANGE["MAX"]) {
        pitch -= OCTAVE;
    }
    return pitch;
}

function roundToPlaces(value, decimalPlaces) {
    return Math.round(value * Math.pow(10, decimalPlaces)) / Math.pow(10, decimalPlaces);
}
